Skip to content

fix: only this session's own worktree counts as its worktree - #15

Merged
Steel-tech merged 2 commits into
mainfrom
fix/worktree-branch-identity
Aug 8, 2026
Merged

fix: only this session's own worktree counts as its worktree#15
Steel-tech merged 2 commits into
mainfrom
fix/worktree-branch-identity

Conversation

@Steel-tech

@Steel-tech Steel-tech commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

#14 shipped --bg --worktree with a review finding left unresolved, plus two more the same check was hiding. All three are the same bug class: something at the recorded path that is not this session's worktree being treated as if it were.

In the feature whose entire purpose is isolation, that means a resumed agent commits its work somewhere it doesn't belong.

The three cases

locate accepted recorded_branch but never passed it to inspect, and inspect asked only whether the path was a directory belonging to the same repository.

At the recorded path Was Now
A worktree of the same repo on another branch Live → resume commits to that branch Occupied
A regular file Gone → recreate, then git worktree add fails without ever naming the file Occupied
A dangling symlink Gone (Path::exists follows links and says absent) Occupied
A detached worktree Live Occupied

The detached case is worth calling out, because the obvious implementation gets it backwards. git symbolic-ref -q HEAD exits nonzero when detached, so a check shaped !status.success() || name == branch reads command failure as agreement and accepts exactly the case it should refuse. Verified against real git rather than reasoned about. Resuming onto a detached head would leave the work unreachable by the branch name the session recorded.

Verification

Four git-backed tests, and every one was mutation-tested — the fix reverted, the test confirmed failing, the fix restored:

Guard With fix Bug reintroduced
worktree on another branch pass fails
regular file at the path pass fails
dangling symlink at the path pass fails
detached worktree at the path pass fails

That step is not ceremony here. This repo has already shipped a regression guard that passed with its bug still present, which is how the branch-identity bug reached main in the first place.

Workspace: 131 tests pass, clippy clean under -D warnings, fmt clean.

One test detail: git keeps its administrative entry for a deleted worktree, so a second worktree add at the same path is refused with "missing but already registered". The tests call git worktree prune first — which is also what a real user clearing a directory would hit.

Post-Deploy Monitoring & Validation

No runtime change beyond the corrected classification. The observable difference: resuming a session whose recorded path now holds something else fails with Occupied, naming the path and expected branch, instead of silently running there. A report of resume newly refusing where it used to proceed is this fix working — check whether the path really holds that session's own branch.

Refs #13


Compound Engineering
Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved worktree detection for missing paths, files, dangling links, and unrelated repositories.
    • Prevented reuse of worktrees checked out to a different branch or in a detached state.
    • More accurately identifies valid, available worktree locations.
    • Added safeguards to verify that recorded worktrees still match the expected repository and path.

`locate` took `recorded_branch` but never passed it to `inspect`, so any
worktree of the same repository at the recorded path counted as the
session's own. Remove a session's worktree, create another at that path on
a different branch, resume the session — and the agent commits its work to
whatever branch it found. In the feature whose entire purpose is isolation.

inspect now requires the checked-out branch to match, alongside the shared
git directory and the worktree top level. A detached HEAD deliberately does
not match: the recording names a branch, and resuming onto a detached head
would leave the work unreachable by that name.

Also: a regular file at the recorded path was reported Gone, which sends
resume down the recreate path where `git worktree add` fails on the
occupied path with an error that never mentions the file. Anything that
exists but is not the session's worktree is now Foreign, so resume reports
Occupied and names what is in the way.

Found by CodeRabbit on #14 and left unresolved when that PR merged.

Refs #13

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PfAfAujueuZ3rDTiL9apx3
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Worktree inspection validates repository identity, top-level path, branch, and path type. Detached, differently branched, foreign, file, and dangling symlink paths are treated as occupied. Integration tests cover each case.

Changes

Worktree validation

Layer / File(s) Summary
Worktree inspection and regression coverage
crates/cli/src/worktree.rs
locate passes the recorded branch to inspect. Inspection validates directory type, repository identity, top-level path, and branch. Tests cover alternate branches, regular files, dangling symlinks, and detached worktrees.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: only the session's own worktree is accepted during resume.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/worktree-branch-identity

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/cli/src/worktree.rs`:
- Around line 272-276: Update the path classification logic around the
exists/is_dir checks to use symlink_metadata() instead of Path::exists(),
returning Candidate::Gone only when metadata reports the path is genuinely
missing and Candidate::Foreign for dangling symlinks or other metadata errors.
Preserve the existing directory classification behavior for resolvable paths.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a81ef1e7-c619-4a81-b25c-1e6deab6cb7c

📥 Commits

Reviewing files that changed from the base of the PR and between af6cf94 and 7e9bf87.

📒 Files selected for processing (1)
  • crates/cli/src/worktree.rs

Comment thread crates/cli/src/worktree.rs Outdated
@Steel-tech

Copy link
Copy Markdown
Contributor Author

Worked the same finding in parallel (my commits are on feat/bg-worktree-isolation at 7d8bccf, orphaned now that #14 merged). Same conclusion on the branch check — one substantive disagreement, on the detached-HEAD rule.

head_branch(path).is_some_and(|head| head == branch) makes a detached HEAD Foreign, so resume refuses with "what stands there is not that worktree". But HEAD is detached for the whole of a git rebase, and an interrupted rebase is the first example the module doc gives for why nothing is ever removed:

a worktree may hold the only copy of an agent's work: an uncommitted diff, a half-finished rebase, a file the agent wrote but never mentioned

So an agent that runs git rebase (or git checkout <sha>, or git bisect) and gets interrupted now cannot be resumed at all — the retention policy keeps the work and the identity check refuses to go back to it. That is a worse failure than the one being closed, and it fires on an ordinary agent action rather than on someone deliberately reusing bullpen's private worktrees/<uuid> path.

The version I pushed treats detached HEAD as agreement and only a different named branch as proof of a different worktree:

/// Whether the worktree at `path` still answers for `branch`. A detached
/// HEAD counts: an interrupted rebase or a `git checkout <sha>` the agent
/// ran is the work this module exists to keep, and refusing to resume into
/// it would strand exactly that. Only a different *branch* proves a
/// different worktree.
fn head_agrees(path: &Path, branch: &str) -> bool { ... }

The residue that leaves — a stranger worktree at the recorded path that also happens to be detached — needs someone to delete the session worktree, prune, and add a new detached one at that exact UUID path. Cheap to give up; a mid-rebase resume is not.

One smaller thing: !path.exists() follows symlinks, so a dangling symlink at the recorded path reads as Gone and sends resume into recreate, where git worktree add fails on the occupied path — the same error this PR fixes for regular files. std::fs::symlink_metadata(path).is_err() is the presence test that covers both.

The rest of your version is better than mine: the separate focused tests read more clearly than my extended one, and the empty-output guard in head_branch is worth keeping.

Not touching this branch — flagging it for you to decide.

Two more ways the recorded path can hold something that is not this
session's worktree.

`Path::exists` follows symlinks, so a dangling link at the recorded path
reported itself absent. Resume then took the recreate branch and git refused,
because the link does occupy the path. `symlink_metadata` sees the link
itself.

A detached worktree has no branch to agree with, and accepting one would let
a resumed session commit where the recorded branch name can never reach the
work again. Worth stating because the obvious shape gets it backwards:
`git symbolic-ref -q HEAD` exits nonzero when detached, so treating command
failure as agreement silently accepts exactly the case it should refuse.

Refs #13

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PfAfAujueuZ3rDTiL9apx3
@Steel-tech Steel-tech changed the title fix: a worktree on another branch is not this session's fix: only this session's own worktree counts as its worktree Aug 8, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/cli/src/worktree.rs (1)

275-279: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject symlink paths in inspect.

path.is_dir() follows symlinks. A symlink target can pass all repository, top-level, and branch checks, causing locate to return Location::Use for the target checkout. Use symlink_metadata and require metadata.is_dir(). Add a regression test with a symlink to the anchor and a matching recorded branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/cli/src/worktree.rs` around lines 275 - 279, Update inspect’s path
validation around symlink_metadata so it rejects symlink paths by requiring the
returned metadata to be a directory, rather than using path.is_dir(). Add a
regression test covering a symlink to the anchor with a matching recorded
branch, ensuring locate does not return Location::Use for the target checkout.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/cli/src/worktree.rs`:
- Around line 275-279: Update inspect’s path validation around symlink_metadata
so it rejects symlink paths by requiring the returned metadata to be a
directory, rather than using path.is_dir(). Add a regression test covering a
symlink to the anchor with a matching recorded branch, ensuring locate does not
return Location::Use for the target checkout.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ed1e3f66-a713-474a-8a05-6c7e82b3e263

📥 Commits

Reviewing files that changed from the base of the PR and between 7e9bf87 and 0b1ac24.

📒 Files selected for processing (1)
  • crates/cli/src/worktree.rs

@Steel-tech
Steel-tech merged commit 2d445fb into main Aug 8, 2026
5 checks passed
@Steel-tech
Steel-tech deleted the fix/worktree-branch-identity branch August 8, 2026 10:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant